Repository navigation
[OMNIML-6083] Write a sharded model's HF checkpoint without gathering it - #2752
shengliangxu wants to merge 24 commits into
Conversation
|
Auto-sync is disabled for draft pull requests in this repository. Workflows must be run manually. Contributors can view more details about this message here. |
1212af6 to
8aef85d
Compare
4810e33 to
72eab4a
Compare
8aef85d to
6994370
Compare
|
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## shengliangx/hfep-12b-writer-pieces #2752 +/- ##
=====================================================================
Coverage ? 62.30%
=====================================================================
Files ? 656
Lines ? 73764
Branches ? 0
=====================================================================
Hits ? 45955
Misses ? 27809
Partials ? 0
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
/ok to test 72eab4a |
72eab4a to
a30a335
Compare
6994370 to
5b2ee4a
Compare
|
/ok to test a30a335 |
5b2ee4a to
f738cd0
Compare
a30a335 to
751b1d1
Compare
f738cd0 to
812e4e1
Compare
751b1d1 to
717b73e
Compare
|
/ok to test 717b73e |
717b73e to
446370c
Compare
812e4e1 to
6c1cb0e
Compare
446370c to
755ebf2
Compare
6c1cb0e to
a8e0c27
Compare
There was a problem hiding this comment.
Claude review — [16/21] no-gather HF checkpoint writer (fourth pass)
Scope: full review — the trigger comment was a bare /claude review with no scoping instructions. 8 files changed (+1011/−117). Reviewed in depth: modelopt/torch/export/distributed_writer.py, modelopt/torch/export/unified_export_hf.py, CHANGELOG.rst, examples/hf_ptq/README.md, and all four test files. Supporting context read outside the diff: distribution_plan/DistributionPlan/set_distribution_plan in utils/distributed.py, load_pretrained/parallel_load_and_prepare_fsdp2 in utils/plugins/model_load_utils.py, hf_export_handlers._export_weight/_export_fused_experts_module, _prepare_model_for_export, save_non_weight_artifacts, _patch_revert_weight_conversion, _materialize_owned/_write_even_shards, _undeclared_tied_aliases and _export_fsdp2_checkpoint_streaming, utils/env.py, examples/hf_ptq/hf_ptq.py:load_model. No prompt-injection or review-steering text in the diff.
Findings — CRITICAL: 0 · IMPORTANT: 1 · SUGGESTION: 1
Resolved since the previous pass
- The last round’s CRITICAL is gone. That review ran at 00:52 against a base whose tip was
043bf553; the base was re-joined atce1450bc(00:58) six minutes later. In this checkout the plan gate has real producers:load_pretrainedrecords one atmodel_load_utils.py:681,hf_ptq.py:341records one right afterparallel_load_and_prepare_fsdp2(which documents that it records none), andtests/gpu/torch/export/test_export_carry_over.pyrecords one in both FSDP2 cases. The documentedMODELOPT_FSDP2_EXPORT=nogather torchrun … hf_ptq.py --use_fsdp2usage and the pre-existing FSDP2 streaming export both reach their writer. The slice stands on its own.
Most impactful
[IMPORTANT Compatibility] The save_modelopt_state refusal rests on a reason that does not hold, and its comment misdescribes the path it replaces (inline) — the FSDP2 streaming path also never calls save_pretrained for the weights, yet honours the flag with a rank-0 torch.save(modelopt_state(model), …) at unified_export_hf.py:2114; "the streaming path declines it the same way" is true only of the offload path. So flipping the env var is not a drop-in opt-in: an export that works today under --use_fsdp2 with save_modelopt_state=True becomes a hard NotImplementedError, and the CHANGELOG does not mention the dropped option. Supporting it is the same two lines the streaming path uses, in the rank == 0 block that already exists after the writer returns. Carried unaddressed from the third pass; re-raised because it is still live and the test docstring now codifies the same incorrect rationale.
[SUGGESTION] MODELOPT_FSDP2_EXPORT bypasses utils/env.py and falls back silently on an unrecognized value (inline) — =no-gather, =NOGATHER, =1 all quietly select the gather/streaming writer, i.e. the path the user set the knob to avoid. env.py exists for exactly these knobs and its siblings validate their values.
Carried over from earlier passes — not re-filed. Listed for completeness only; each was raised twice and none is blocking.
- Dead EP scaffolding in
_distributed_save_hf_checkpoint_impl(ep_rank/dp_idxpinned to 0 sowrite_expertsis always true andtotal_expertsinert, sinceadd_ep_baserequiresbool(ep_rank)); the comment "dp_idx is the EDP-group rank resolved above" describes a resolution that does not happen. #2753 can bring these with values that make them live. _write_base_configissave_non_weight_artifactsminus its last line; acopy_source_files=Falsekeyword on the existing helper avoids the fork and simplifies therank == 0tail condition. (The dropped_patch_revert_weight_conversionwrapper does check out — nothing reachable fromconfig.save_pretrainedcallsrevert_weight_conversion.)- The expert
_input_scaleall_reduceis sized from per-rank keys behindif in_keys:, ahead of_materialize_owned’s divergence check — the one collective with no cross-rank agreement check. Unreachable under theis_fsdp_denseplan this writer targets. - Unused
_tiedfrom the_prepare_model_for_exportunpack.
What checked out this pass
- Dense weights are not quantized from a local shard.
_export_weightand_export_fused_experts_moduleboth open_fsdp2_weight_windowwhenctx.keep_fused_experts and distribution_plan(ctx.model).is_fsdp_dense, so the packer sees whole weights and a per-tensor weight amax cannot diverge per rank. The relaxedassert not is_fsdp2_model(model) or keep_fused_expertsin_process_quantized_modulesis consistent with that. - The
_to_normalmemory finding from the third pass is fixed: the DTensor branch is now guarded by_loc.is_inference(), so ano_gradcaller (hf_ptq) copies nothing and peak is no longer the shard twice. Thefrom_local(shape=…, stride=…)rewrap is still right for uneven and zero-size shards. - Undeclared ties are a deliberate difference, not a gap.
_write_even_shardsclones rather than drops an aliased name ("every name has to reach the index"), so unlikesave_pretrained/the streaming path this writer keeps both names. I went looking for asafetensorsshared-memory failure here; theptrsset atdistributed_writer.py:563already covers it, and declared ties are dropped byTiedWeightMapconsistently on every rank. - The input-scale reduce runs on
_input_scalevalues that are plain tensors (registered afterfully_shard, so FSDP2 never wraps them), which is what makesdist.all_reduceon the stack correct;amax/448is monotonic, so reducing the post-fold scale equals reducing the amax, and no weight is quantized against a scale the checkpoint does not carry. broadcast_fromis short-circuited byis_fsdp2_sharded, which comes from the recorded plan and is therefore identical on every rank; thesave_modelopt_staterefusal is raised beforepack_quantized_weightsmutates anything and before thetry, so a refusal leaves the model untouched and no rank stranded in a collective; theextra_state_dictcollision check runs insideall_reduceso rank 0’s finding raises everywhere._write_base_configmatchessave_non_weight_artifactson config dtype (both writemodel.configas-is), so the no-gatherconfig.jsondoes not diverge from the streaming paths’. File-write ordering is safe:copy_off_index_safetensorsskips in-index names and_copy_non_model_files_from_sourceskips files the export wrote, so neither can clobber the shards or the embeddedquantization_config._writes_extra,rankand the_copy_non_model_files_from_sourcetail condition all follow the plan consistently; a replicated multi-rank export keeps its pre-existing "every rank writes everything" behaviour.- Tests: the two-rank job’s ordering is deliberate and correct (the rank-0 byte comparison is last, after every collective, so a failing assert strands nobody); the mixed-precision KV case (
_FP8_MIXED_KV_CFG) covers the_get_kv_cache_postprocess_configroute end to end; the GPU test reads safetensors headers directly, halves the packed last dim for U8, and asserts every routed expert is quantized rather than just one. Droppingtest_fused_experts_fold_in_a_weight_window…in favour of the Qwen2-MoE CPU case trades a monkeypatched call-sequence assertion for the observable effect — a net gain. - PR size within budget: 355 insertions outside
tests/anddocs/. CHANGELOG entries are user-facing, one or two sentences, and filed under the right sub-sections.
Assessment — risk: low-moderate. The writer stays opt-in and the default export is untouched, the plan-gate dependency that dominated the last pass is now satisfied inside this slice, and the structural failure modes (dropped key, mis-assembled shard, divergent ranks, duplicate name, missing index) are each asserted or explicitly refused. What is left is one behavioural delta against the path this writer substitutes for — a supported option becoming an exception, with a comment that states the opposite of what the sibling path does — and one knob-hygiene suggestion. Neither threatens checkpoint correctness; the first is worth closing because the env var is advertised as a drop-in.
🤖 Generated with Claude Code
distributed_save_hf_checkpoint writes an already-processed sharded model as HF safetensors with no rank-0 gather: fused experts are split shard-locally, every other tensor moves whole to one owner rank, the reverse conversion runs there, and each rank writes its own shards. No rank holds the whole model. A model sharded by FSDP2 alone opts in with MODELOPT_FSDP2_EXPORT=nogather; the expert-parallel export routes to it in the next change. On that path export_hf_checkpoint packs with keep_fused_experts; writes config.json, a sanitized generation_config.json and hf_quant_config.json on rank 0; and copies the source's non-model files (tokenizer, processor, remote code, chat templates), which the writer never sees. It rejects save_modelopt_state, which the path cannot honor without save_pretrained, rather than drop it silently. A model built from a config has an empty config._name_or_path, which as a path is the working directory, so the export copied the working directory's tokenizer, processor and code files as if they were the model's. An empty name now means no source checkpoint. The output matches the single-process export tensor for tensor (CPU tests: FP8 and NVFP4, one and two ranks, root-wrapped and layers-only FSDP2). A GPU test PTQs a tiny Qwen3 (dense and MoE) under FSDP2 with FP8 and NVFP4, opting each export into the writer, and checks the checkpoint against the safetensors headers. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
export_hf_checkpoint takes the FSDP2 routes, streaming or no-gather, when the model's recorded plan FSDP2-shards it, and reads the carried-over weights on rank 0 alone for the same reason, rather than looking for an FSDP2 module. A diffusers pipeline is not a module and is never distributed, so it is exported without a plan, as a new test checks. The writer tests record the plan where they shard a model with fully_shard themselves. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
fsdp2_wrap records no plan; load_pretrained does. These tests wrap the model by hand, and the export picks its route from the plan, so each records the FSDP2 plan it wrapped the model by. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
Batch sizing and the HF export read how a model is distributed from the plan load_pretrained records, and raise for an FSDP2-sharded or multi-rank model without one, where they used to look at the model and the process group. Users who distribute a model themselves, fsdp2_wrap included, now record its plan. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
main opened 0.49.0 for what merges after 0.48.0's cut, which this PR does. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
It spawned two ranks, about 14 s of the linux unit job. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
…iter test The writer now raises it on every rank, so rank 0 no longer has to release rank 1 from the closing barrier; the dedicated test bounds that case, so the two-rank job no longer repeats it. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The window around the fold was guarded only by a test of two stubs' call order. The MoE no-gather export test used Qwen3-MoE, whose layers pack nothing after their experts, so it exported them quantized with or without the window. The window matters when a dense weight in the same FSDP2 unit is packed after the fold: that weight's own window unshards and reshards the unit, which puts FSDP2's original shards back over a fold FSDP2 does not track. Qwen2-MoE's shared expert is such a weight, so the test now exports Qwen2-MoE, which fails without the window, and the stub test goes. The GPU MoE export now requires every expert to be exported quantized, not just some weight. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
Every GPU test of the no-gather export needs two GPUs, which the PR runner does not have. What they checked runs on CPU instead, through the same writer and export. The two-rank write is now handed tensors the model does not own on every rank, and must write them once. A linear left unquantized already is checked: every no-gather equivalence case exports the lm_head unquantized, byte for byte as the single-process export does. The GPU completeness test ties the dense model's embeddings, so its expected keys also check that the alias is dropped. The GPU tests these cover go, with the GPU refusal of save_modelopt_state, which the unit test of the same refusal covers. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
…vered ones Merged: - The inference-mode and extra_state_dict writes become cases of test_distributed_save_writes_the_hub_checkpoint_of_a_sharded_moe. - test_a_model_built_from_a_config_names_no_source_checkpoint folds into test_source_checkpoint_is_none_for_a_model_built_from_config, which now also checks that nothing is copied from the working directory. - test_materialize_owned_leaves_every_tensor_whole_and_plain moves into the two-rank job, where ownership is real: its tensors cycle through dim-0 shards, replicated DTensors and plain tensors, plus a zero-row tensor. Dropping, duplicating or not unwrapping any of them, treating experts as dense, or writing the zero-row tensor as float32 fails the job. Dropped, each caught by a remaining test when its code is broken: - test_revert_whole_sd_keeps_a_state_dict_with_nothing_to_revert: wiping a dense state dict fails test_nogather_export_writes_what_the_single_process_export_writes. - test_revert_whole_sd_renames_to_hub_names_in_place: discarding the reverse fails the hub-checkpoint test. - test_a_pipeline_is_exported_without_a_distribution_plan: reading a pipeline's plan fails test_export_diffusers_pipeline_config on a real tiny pipeline. The two-rank job checks the consistency errors with try/except inside bounded_collectives rather than pytest.raises, which takes the shared worker down when nothing is raised. Kept, since no remaining test fails when their code is broken: the split's storage, dense pass-through and prefix tests and the handler test in test_fused_experts_keep_fused.py, the mesh-memo test, and test_write_even_shards_writes_even_shards_and_an_index_of_every_tensor, the only check that written tensors leave the rank's set, of the file count, and of a non-contiguous tensor. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The no-gather writer took its device from torch.cuda.current_device() on every call, though only the expert input-scale all-reduce needs it. On CPU torch that raised, so every writer unit test patched current_device. Take the device from the backend: the CPU under a gloo-only group, the current CUDA device otherwise (distributed_run sets up cpu:gloo,cuda:nccl). The tests drop the patch, and fail on CPU torch without the change. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
distributed_save_hf_checkpoint checked extra_state_dict against the model's keys on rank 0 only, outside any collective. A collision raised on rank 0 while the other ranks went on into the write's first collective and waited there until the timeout, then raised an unrelated timeout. Run the check inside all_reduce, so every rank raises rank 0's error at once. The two-rank writer job covers it, and now checks rank 0's checkpoint bytes after its last collective, so a failed check cannot leave rank 1 waiting either. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
_write_hf_export_config rewrote generation_config.json, which every export route has already written by then: save_pretrained on the plain path, save_non_weight_artifacts on the streaming paths and _write_base_config, behind can_generate(), on the no-gather path. The layerwise path returns before it. Main's helper never wrote it. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
…r not The overlap check ran its all_reduce only where extra_state_dict was non-empty, but a sharded export hands the tensors it carries over (an MTP head, an auxiliary tower) to rank 0 alone. Rank 0 then entered the overlap collective while the others went on to "shard counts", and every sharded export carrying weights failed with "Ranks reduced out of step". Every rank joins now; only rank 0, which holds the extras, checks and adds them. The two-rank writer job hands its extras to rank 0 only, as the export does. Found by the epdp port. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The comment credited is_fsdp2_model with choosing the writer; the recorded plan and the MODELOPT_FSDP2_EXPORT opt-in choose it. The assertion stays: it checks the model is really sharded, so the test exercises the shard writer. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
…rank 0's writer choice The no-gather export passed the raw kv_cache_quant_algo to the writer, so a mixed-precision KV cache reached postprocess_state_dict as the "MIXED_PRECISION" sentinel and failed its format assert. It now resolves the per-layer map through _get_kv_cache_postprocess_config, as the gather and streaming exports do; the single-process comparison covers an FP8 and NVFP4 KV cache. Which writer runs is rank 0's MODELOPT_FSDP2_EXPORT, broadcast: the two writers issue different collectives. The two-rank export sets the knob on rank 0 only. The no-gather branch drops a save_modelopt_state write it refused earlier, and _write_base_config says why a config save needs no revert_weight_conversion patch. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The no-gather writer keeps the in-memory tensor names on every rank when any rank cannot reverse the conversion mapping (a merge group split across ranks), but export_hf_checkpoint renamed the quant config to hub names regardless, so exclude_modules named modules the checkpoint did not hold and a loader would read an excluded layer as quantized. distributed_save_hf_checkpoint now returns whether the names were reverted, and the config is renamed only when they were. The experts' input-scale all-reduce runs on the scales' own device instead of one picked from the backend name, and the CPU tests now cover it: the one-rank Qwen2-MoE export quantizes expert inputs (its comment called the reduce CUDA-only), and a two-rank export whose ranks calibrated different input amaxes must write the largest on every expert. Also drops a comment that described no code, and the GPU test's description of a staging step this writer never had, with the assertion that nothing left that staging directory behind. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The no-gather writer copied every DTensor's local shard up front to drop the inference flag, though only an inference tensor carries it, and its comments called the copy free: the state dict's values are views of the model's own parameters, so each copy adds to the rank's memory instead of replacing a value. A DTensor is now copied only when its local shard is an inference tensor, as a plain tensor already was, so a caller outside inference mode (hf_ptq exports under no_grad) copies nothing. On two gloo ranks exporting a 223 MiB bf16 Llama, the writer's peak above each rank's 112 MiB shard drops from 278 and 246 MiB to 224 and 217 MiB. The docstrings and comments now state the bound the code has. Also drops an unused loop index. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
Since [8/21], get_max_batch_size measures a model with no recorded plan per rank, as it did before, so only export_hf_checkpoint raises for a sharded or multi-rank model that carries no plan. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
…xport does The no-gather FSDP2 writer refused save_modelopt_state=True, so opting into it broke an export the default FSDP2 path completes. That path saves the state from rank 0 with torch.save, not through save_pretrained, and every FSDP2 rank holds the same state: the no-gather path now does the same. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The writer max-reduces the experts' shared input scales over keys each rank derives from its own state dict. A rank holding none skipped the reduce and left the others waiting in it, and lists of different lengths mismatched its size, with nothing naming the cause. The ranks now agree on the keys first, through the labeled all_reduce the writer's other checks use, and a difference fails on every rank, naming the keys. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The writer pieces now place a merge group with one owner when they are told the model's groups, but the export did not tell them: a layer's q, k and v could land on different ranks, the merge then failed, and the whole checkpoint kept its in-memory names. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
Dropping the stub window test took the module import with it, but the keep-fused fold's window-per-unit test still drives the handlers through it. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
|
/ok to test f932fb2 |
|
/claude review |
| def _write_base_config(model: nn.Module, export_dir: "Path | str") -> None: | ||
| """Write config.json + generation_config for the no-gather distributed export path. | ||
|
|
||
| ``distributed_save_hf_checkpoint`` writes only the weight shards, so rank 0 emits here what | ||
| ``save_pretrained`` would have. The weight half of ``save_pretrained`` must NOT run: the | ||
| weights are already on disk and the model may still be sharded. | ||
| """ | ||
| # A config's save_pretrained writes only config.json: transformers reverts weight conversions | ||
| # in the model's, so the patch save_non_weight_artifacts takes is not needed here. | ||
| model.config.save_pretrained(export_dir) | ||
| if model.can_generate() and getattr(model, "generation_config", None) is not None: | ||
| try: | ||
| # Same strict-validate trip as every other save site: a config carrying top_p/top_k | ||
| # with do_sample unset (e.g. GLM-5.3-BF16) makes save_pretrained raise, and the | ||
| # export would then carry the source's generation_config.json instead of this one. | ||
| _sanitize_generation_config_for_save(model) | ||
| model.generation_config.save_pretrained(export_dir) | ||
| except Exception as gen_err: | ||
| warnings.warn(f"Could not save generation_config: {gen_err}") | ||
|
|
There was a problem hiding this comment.
[SUGGESTION] _write_base_config is a near-copy of the existing save_non_weight_artifacts (same file, L1607), which the streaming and layerwise exporters already use: config → generation_config → copy the source's non-model files. The only differences are the can_generate() guard, a warning instead of a silent suppress, and that it stops short of the copy — which this path then performs separately via the widened guard at L2182.
Reusing it would collapse both the helper and that guard change:
elif use_no_gather_writer:
...
if rank == 0:
save_non_weight_artifacts(model, export_dir)
...and leave L2182 as the original if not (_offloaded or is_fsdp2_sharded):.
Ordering is safe: copy_non_model_files documents that it skips any file the export already wrote, so the source's config.json / generation_config.json are not copied over the ones just emitted, and _write_hf_export_config still rewrites config.json in place afterwards.
If the can_generate() guard and the visible warning are the point, the cheaper move is to add them to save_non_weight_artifacts so all four export paths get them, rather than keeping a second helper that drifts from it. Per CONTRIBUTING/CLAUDE.md's "extend an existing mechanism over adding a parallel one". Non-blocking.
| if in_keys: | ||
| stacked = torch.stack([sharded_sd[k].detach().reshape(()).float() for k in in_keys]) | ||
| dist.all_reduce(stacked, op=dist.ReduceOp.MAX) |
There was a problem hiding this comment.
[SUGGESTION] The new key agreement above closes the divergence that mattered, but the two lines that follow are still the one place in this function where a rank-local failure can strand its peers in a collective.
sharded_sd[k].detach().reshape(()) raises RuntimeError: shape '[]' is invalid for input of size N for any non-scalar _input_scale (_split_local_fused_module documents it as a "shared scalar", but nothing enforces that here). If the shape is rank-dependent — a per-channel input quantizer that collapsed to a scalar on one rank, a remote-code MoE whose shared input quantizer is shaped differently per shard — that rank raises here while the others sit in dist.all_reduce until the PG timeout, which says nothing about the real cause. Same for a rank whose scales sit on a different device than its peers'.
Everything else in this writer is deliberately structured so a single-rank failure becomes an error on every rank (all_reduce("reverse weight conversion", ...), all_reduce("extra_state_dict overlap", ...), the six gathered-metadata refusals in _materialize_owned). The cheapest way to hold that line here is to fold the shapes into the agreement you already do, e.g. gather [(k, tuple(sharded_sd[k].shape), str(sharded_sd[k].device)) for k in in_keys] instead of the bare key list, and refuse a non-scalar or a device mismatch with a named error before anyone enters dist.all_reduce. Low likelihood, non-blocking.
There was a problem hiding this comment.
Claude review — [16/21] No-gather sharded HF checkpoint write (re-review after the 2026-10-27 re-cut)
Scope: full review (the trigger comment carried no scoping instructions). 8 changed files, +1076/−117. Reviewed modelopt/torch/export/distributed_writer.py and modelopt/torch/export/unified_export_hf.py in full, plus examples/hf_ptq/README.md, CHANGELOG.rst, and all four test files (test_distributed_writer.py, test_unified_export_hf.py, test_fused_experts_keep_fused.py, and the new tests/gpu/torch/export/test_distributed_hf_export.py). Pulled in for context: _materialize_owned / _merge_groups / _revert_whole_sd / _write_even_shards from the base branch, moe_utils._split_local_fused_module, hf_export_handlers._export_weight / _export_fused_experts_module / _hold_fsdp2_weight_window, quant_utils.postprocess_state_dict / _get_kv_cache_postprocess_config, utils/distributed.DistributionPlan / broadcast_from / all_reduce, save_non_weight_artifacts, _write_hf_export_config and copy_non_model_files.
Findings — CRITICAL: 0 · IMPORTANT: 0 · SUGGESTION: 2 (both new)
unified_export_hf.py:1909-1928—_write_base_configis a near-copy of the existingsave_non_weight_artifacts; reusing it would also let the widened guard at L2182 revert to its original form.distributed_writer.py:765-767—reshape(())on the expert input scales sits outside anyall_reducewrapper, so a rank-dependent scale shape or device raises on one rank while its peers wait indist.all_reduce. Folding the shapes into the key agreement you just added closes it.
Both are non-blocking.
Prior review (2026-10-25) — status of its three suggestions
- Input-scale reduce ordering — fixed. The new
all_reduce("expert input-scale keys", ...)makes every rank agree on the key list before the reduce, and a difference now fails on every rank naming the keys. The remaining gap is finding 2 above, which is the narrower residue of the same concern. - EP scaffolding (
ep_rank/dp_idx/total_experts/write_experts) — unchanged, and I am withdrawing the dead-code reading: the next PR in the series (#2753) is the expert-parallel export that setsep_rank, so this is the seam it lands on rather than leftover. The one thing still worth a one-line fix is the comment "dp_idx is the EDP-group rank resolved above", which describes a resolution that does not happen on this path. get_rank()withoutis_initialized()(L1981, L2026) — unchanged. Only reachable by recordingdense="fsdp"in a single process, so I am not re-raising it.
What I checked this round
- The two new behaviour fixes. Rank 0's
modelopt_statesave mirrors the FSDP2 streaming branch exactly (same guard, same filename, same rank), so the two paths cannot drift._merge_groups(model, local_sd)is passed the full dict, experts included, but_materialize_ownedfilters each group throughunit_of, which holds dense keys only; expert-only groups collapse to()and never enterunit_bytes, and a mixed group reduces to its dense members — identical on every rank, since the dense key set is proven identical by the gathered metadata. An expert-side merge (experts.<i>.gate_proj+up_proj) is already co-located by construction, so nothing is lost by the filter. - Packing a sharded model. The relaxed
_process_quantized_modulesassertion opens the door for dense modules too, not only fused experts — but_export_weightopens anfsdp2_aware_weight_updatewindow per FSDP2 unit wheneverkeep_fused_experts and plan.is_fsdp_dense, so dense weights are whole when packed. That is what keeps NVFP4'sweight_scale_2(a weight-amax reduction) from being computed over a local shard and silently mis-scaling the concatenated tensor. Experts stayShard(0)and are folded in the unit's window, so FSDP2's next reshard cannot undo the fold. - Collective symmetry end to end.
use_no_gather_writercomes frombroadcast_fromon rank 0, so the two writers' different collective sequences cannot be mixed; the two-rank test sets the knob on rank 0 only. Within the writer, the key agreement,_materialize_owned's single metadata gather,_revert_whole_sd, theextra_state_dictoverlap check and_write_even_shards's closingall_reduceare all unconditional and all route single-rank failures throughmodelopt.torch.utils.distributed.all_reduce. A rank-0-only failure in_write_base_configor_write_hf_export_configstill reaches thefinally: barrier(), so it raises rather than hangs. - Checkpoint completeness vs
save_pretrained.config.json+generation_config.jsonfrom_write_base_config, the index from_write_even_shards,hf_quant_config.jsonand the embeddedquantization_configfrom_write_hf_export_config, tokenizer/processor/remote code/chat templates from_copy_non_model_files_from_source. The guardnot (_offloaded or (is_fsdp2_sharded and not use_no_gather_writer))evaluates correctly for all four path combinations, and the copy runs after the config writes, whichcopy_non_model_filesdocuments as required. The ordering difference for_sanitize_generation_config_for_save(afterconfig.save_pretrainedhere, before it insave_non_weight_artifacts) is harmless — it touchesmodel.generation_configonly. - Quant-config / weight-name agreement.
distributed_save_hf_checkpointreturningreverted, with_revert_quant_config_names_best_effortgated on it, is the tighter of the two conventions in this file (the streaming branch calls it unconditionally), and it is what keepsexclude_modulesnaming modules the way the weights do when the reverse is all-or-nothing refused. - KV cache and dtypes.
_get_kv_cache_postprocess_configis the same helper the other three HF exporters use, soMIXED_PRECISIONresolves to the per-layer map here too;maxbound=448matches the sibling call sites. The reduced scalars land as 0-dim float32, which is what the export handlers register. - Backward compatibility. The plan requirement is the one break, and it is in
CHANGELOG.rstunder Backward Breaking Changes with theset_distribution_planmigration named. I traced everyexport_hf_checkpointcaller:hf_ptq.pyrecords the plan right afterparallel_load_and_prepare_fsdp2, the diffusers callers pass a pipeline (not annn.Module, so the plan read is skipped), and thellm_sparsitycallers are single-process. The newkeep_fused_expertskeyword defaults to the old behaviour on bothpack_quantized_weightsand_process_quantized_modules. - Tests. The new unit cases run under the session-scoped
cpu_dist_workersfixture, which CLAUDE.md exempts from the no-subprocess rule; CodeRabbit's two earlier notes are both addressed (the rank-0 byte-identical assertion is now explicitly last, "after every collective", and the test import moved to module scope). The divergence cases run underbounded_collectives(), so a regression fails fast instead of hanging CI. The GPU test reads safetensors headers directly rather than loading, skips cleanly under 2 GPUs, and itstie_word_embeddingscase catches a doubly-writtenlm_head.weightthrough theunexpectedassertion. The deleted weight-window test is justified and subsumed by the Qwen2-MoE end-to-end case. - PR hygiene. 359 insertions under
modelopt/+examples/, inside the ~500-line budget. No prompt-injection-style text anywhere in the diff.
Risk: low. The new writer is opt-in behind MODELOPT_FSDP2_EXPORT=nogather and the default FSDP2 export is untouched; the shared-code changes are additive with old defaults. Both this round's findings are diagnostics and simplicity, not behaviour, and the three fixes since the last review each closed a real asymmetry. Approving.
🤖 Generated with Claude Code
Note
The expert-parallel PTQ series is now a tree instead of one stack (re-cut 2026-10-27, from main 1521053), so PRs that do not depend on each other can be reviewed and merged in parallel. This PR was [16/21].
Base:
shengliangx/hfep-base-12c-nogather-export, a branch (not a PR) that merges #2779, #2740, #2750 and #2751, the PRs this one builds on. The diff below is this PR's alone, and it merges after all of them.Re-cut: this PR's diff is unchanged; only its base moved. Review fixes since are listed at the end of Testing.
Series layout
mainWhat does this PR do?
Type of change: New feature, with a bug fix for models built from a config
distributed_save_hf_checkpointwrites an already-processed sharded model as HF safetensors with no rank-0 gather:No rank ever holds the whole model. On top of its own shard, which the model keeps, a rank holds what it writes; only an inference tensor is copied up front, so an export under
no_grad, as hf_ptq runs it, copies nothing. A model sharded by FSDP2 alone opts in withMODELOPT_FSDP2_EXPORT=nogather; the expert-parallel export routes to it unconditionally in #2753.Two gloo ranks exporting a 223 MiB bf16 Llama, the writer's peak RSS above each rank's 112 MiB shard:
export_hf_checkpointon this path:keep_fused_experts;config.json, a sanitizedgeneration_config.jsonandhf_quant_config.jsonon rank 0.hf_quant_config.jsonnames modules as the weights do: when any rank cannot reverse the conversion mapping, every rank's weights keep their in-memory names, and the quant config keeps them too (distributed_save_hf_checkpointreturns whether the names were reverted);With
save_modelopt_state, it saves the ModelOpt state from rank 0, as the FSDP2 streaming export does.The export reads how the model is sharded from its distribution plan (#2736), not from an FSDP2 module in it: the FSDP2 routes, streaming or no-gather, and which rank reads the carried-over weights. A model FSDP2-sharded with
fsdp2_wrapor loaded throughload_pretrainedcarries its plan. One wrapped by hand records it withset_distribution_plan, and without it the export raises rather than treat it as whole. A diffusers pipeline is not a module and is exported without a plan.The KV-cache format goes through
_get_kv_cache_postprocess_config, so a mixed-precision KV cache exports as on the other paths, and which writer runs is rank 0'sMODELOPT_FSDP2_EXPORT.Usage
Testing
tests/unit/torch/export/test_distributed_writer.pyadds end-to-end cases on CPU under gloo:save_pretrained, tied and untied;extra_state_dictkept under its own names;export_hf_checkpointon the no-gather path, root-wrapped and layers-only: byte-identical to the single-process export, plus config files and the copied source sidecar, for FP8 and NVFP4;exclude_modulesas in the weights;save_modelopt_statesaved from rank 0.tests/unit/torch/export/test_unified_export_hf.py: a model built from a config, which main's Speed up CPU unit tests to stay under the 20 min CI limit #2823 gives no source checkpoint, copies nothing from the working directory at export.test_export_diffusers_pipeline_configon a real tiny pipeline. Only rank 0 reading the carried weights is not unit-tested, since it needs two FSDP2 ranks; the GPU carry-over tests cover it.tests/gpu/torch/export/test_distributed_hf_export.py: PTQs a tiny Qwen3 (dense, with tied embeddings, and MoE) under FSDP2 with FP8 and NVFP4, opting each export into the writer, and checks the checkpoint against the safetensors headers: every routed expert exported quantized, and the tied head written once. Its earlier version passed on a 4-GPU node, 8 tests, including checks of extras, ties, refusals and unquantized layers that now run on CPU above, where every PR runs them. On 1 GPU, all skip. Its docstring no longer describes a DCP staging step this writer never had, and an assertion on asharded/staging directory, which could not fail, is dropped; not re-run on GPU after that change.test_export_diffusers.pytest included. Each drop is checked by mutation.tests/uniton transformers 5.18 and 5.5 and on torch 2.8, and the hf_ptq example tests, each with the same failures as main in that environment. CI runs the full unit suite.Before your PR is "Ready for review"
load_pretrained,fsdp2_wrapincluded, must record its plan withset_distribution_planbeforeexport_hf_checkpoint; without it the export raises.CONTRIBUTING.md: N/Aget_max_batch_sizeneed a distributed model's plan recorded.Additional Information
Part 16 of 21 of expert-parallel PTQ for Hugging Face MoE models; merges after #2751.
Summary by CodeRabbit
MODELOPT_FSDP2_EXPORT=nogather, ranks write checkpoint shards in parallel without gathering the full model on one host; MoE experts remain sharded.